iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 10
1
自我挑戰組

寫遊戲初體驗系列 第 10

Day 10 [OpenGL] Hello Traingle

  • 分享至 

  • xImage
  •  

Hello Traingle

首先在OpenGL當中,所有東西都是在3D空間中,然而螢幕卻是個存取pixel的2D陣列。所以OpenGL主要的工作就是經過一系列的操作把3D的坐標系轉換成2D的坐標系。而這操作稱作「圖形渲染管線(Graphics Render Pipeline)」,圖形數據經過一個pipeline,中間經過各種轉換,最終輸出到螢幕上。

  • Render Pipeline 被劃分成多個階段,前一個階段的輸出會作為下一個階段的輸入,每個階段都是高度專門化的
    • GPU 中有成千上萬個小處理核心,為 Pipeline 上的每個階段處理
    • 跑在 GPU 中的小程式稱作 Shader (著色器)
    • OpenGL 使用的 Shader 語言是: OpenGL Shading Language (GLSL)

Render Pipeline 大概流程

  • 輸入 Vertex Data

    • 一個頂點是 Vertex 是 3D 座標的數據的集合
    • Vertex Attribute 頂點屬性表示了一個頂點的資料
  • Vertex Shader 頂點著色器

    • 輸入一個頂點(Vertex),把 3D 座標轉換成另一種座標
    • 對 Vertex Attribute 做一些處理
  • Shape Assembly 圖元裝配

    • 輸入 Vertex Shader 輸出之所有頂點
    • 將 Vertex 裝成指定的形狀
  • Geometry Shader

    • 產生新的頂點用來構造出圖元來生成其他形狀
  • Rasterization 光柵化

    • 轉換成像素(Pixel)
  • 裁切(Clipping)

    • 將畫面外的像素丟掉
  • Fragment Shader 片段著色器

    • 計算最後 pixel 的顏色
  • Test and Belending 測試與混合

    • Depth Test
      • pixel 的深度(Depth),決定像素的前後
    • Alpha Test
    • Blending
      • 物體會有透明度

在現代OpenGL中,我們必須定義至少一個Vertex Sahder和一個Fragment Shader(因為GPU中沒有預設的頂點/片段著色器)

Vertex Input

在繪製圖形之前,必須給OpenGL輸入一些頂點數據。OpenGL是一個3D圖形庫,所以我們在OpenGL中指定的所有坐標都是3D坐標(x, y, z)。
OpenGL 只會處理 3D 座標在值在 $[-1.0, 1.0]$ 的座標,稱作 標準化設備座標 Normalized Device Coordinates (NDC),只有在此座標內的頂點最終才會顯示在螢幕上。

float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

我們將它頂點的z坐標設置為0.0。這樣子的話三角形每一點的深度(Depth)都是一樣的,從而使它看上去像是2D的。

有了頂點資料後,接著要把這些頂點資料放到「顯示記憶體」中,交給 Vertex Shader 處理。可以透過 Vertex Buffer Object (VBO) 來管理。

VBO

  • Vertex Buffer Object (VBO) 頂點緩衝物件
    • OpenGL 最常用到的緩衝物件
    • 用來在 GPU 記憶體中儲存大量頂點
      • 每個頂點的資料通常含有
        • 座標、顏色、貼圖座標、法向量...等
    • 可以一次性的發送一堆頂點到顯卡上(CPU 發送資料相對較慢,因此我們希望一次性發送盡可能多的資料)
    • OpenGL 中以 GL_ARRAY_BUFFER 表示

產生、綁定、傳送資料

VBO就跟其他OpenGL物件一樣,他需要獨一無二的ID

uint32_t VBO;
glGenBuffer(1, &VBO);

OpenGL有很多種Buffer ObjectVBO屬於GL_ARRAY_BUFFER

glBindBuffer(GL_ARRAY_BUFFER, VBO);

從這一刻起,我們使用的任何(在GL_ARRAY_BUFFER目標上的)Buffer調用都會用來配置當前綁定的VBO。然後我們可以調用glBufferData函數,它會把之前定義的頂點數據複製到Buffer的內存中

glBufferData(GL_ARRAY_BUFFER, sizeof(vertives), vertices, GL_STATIC_DRAW);

glBufferData的一些參數說明:

  • glBufferData(GLenum target, GLsizeptr size, const GLvoid *data, GLenum usage)
    • target: 目前緩衝物件綁定到的目標
    • size: 給定緩衝物件的大小
    • data: 要存入資料的 pointer
    • usage: 設定存入資料的使用方式
Usage 描述
STATIC 資料只被設定一次,但會被使用很多次
DYNAMIC 資料被改變很多次,也被使用很多次
STREAM 資料每次繪製都會改變

Vertex Shader

使用現在 OpenGL 至少需要一個以上的 Vertex Shader,以下是 Vertex Shader 的一個例子

#version 330 core  // version
layout (location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
  • in 指得是輸入資料
  • vec[1,4] 指得是向量,有 .x, .y, .z, .w 這幾個 float 分量
  • layout(location = 0 設定了這個變數的 index,在把資料傳入 shader (GPU)時會用到
  • GLSL 的其他 type 可以參考

編譯Shader

我們已經寫好了一個 Shader 的 code 了(通常儲存在字元陣列中),

為了讓 OpenGL 使用他,我們需要在 run-time 時編譯他。

我們要根據 type 創建 Shader 並拿到 shader id

uint32_t vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER)

之後把原始碼綁訂到該 Shader 上,然後編譯他

lShaderSource(vertexShader, 1, &vertexShader, nullptr);
glCompileShader(vertexShader);

如果要查看編譯的狀況要加入以下原始碼

int success;
char log[512];
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if(!success)
{
    glGetShaderInfoLog(shader, 512, nullptr, log);
    printf("Error Shader %s compile error\n%s\n", 
        type == GL_VERTEX_SHADER ? "Vertex" : "Fragment", log);
}

Fragment Shader

Fragment Shader 所做的是計算像素最後的顏色輸出

#version 330 core
out vec4 FragColor;

void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
} 
  • out 指定變數為輸出

  • FragColor 對應到的分量分別是 R, G, B, A

  • 編譯跟 Vertex Shader 一樣

    • type 是 GL_FRAGMENT_SHADER
uint32_t fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

Linking Shader Program

我們要把編譯好的 Shader Link 成一個 Shader Program Object
當我們渲染時啟用該 Shader Program,之後的渲染指令便會去使用該 Shader Program

首先,先建立 Shader Program

uint32_t program;
program = glCreateProgram();

接著將 Shader Attach 到 Program上

glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
glLinkProgram(program);

Link的狀況一樣可以檢查

glGetProgramiv(program, GL_LINK_STATUS, &success);
if(!success)
{
    glGetProgramInfoLog(program, 512, nullptr, log);
    printf("Error Shader Linking error\n%s\n", log);
}

接著我們可以啟用(激活)此 Program

glUseProgram(program);

到了這裡,我們已經把頂點資料存在 GPU 中,而且也指定了要怎麼處理這些資料(Shader),但是 OpenGL 還不知道要如何解析傳入的資料,以及該怎麼將頂點資料連接到 Shader 的參數上,我們指定給 OpenGL。

Link Vertex Attribute

由於 OpenGL 沒有規定傳入頂點資料的格式,這意味著我們可以自己決定,但也必須要我們手動指定給 OpenGL。

根據我們上面訂出的頂點陣列 vertices[] ,有底下幾種屬性是必須告訴 OpenGL 的:

  • 頂點資料是儲存在 float 大小是 sizeof(float)
  • 每個頂點有 3 個 float 資料,分別是 x, y, z
  • 每個頂點之間沒有空隙或是其他的資料,是緊密排列(Tightly Packed)
  • 開始位置是 0

可以使用 glVertexAttribPointer 將頂點資料的資訊告訴 OpenGL 它該怎麼解析這些頂點資料:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
  • glVertexAttribPointer()
void glVertexAttribPointer(
    GLuint index,
    GLint size,
    GLenum type,
    GLboolean normalized,
    GLsizei stride,
    const GLvoid * pointer);

所以到了這裡,我們已經有能力繪製東西在螢幕上了,你的 code 可能會長這樣:

// 建立 VBO 複製頂點資料
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 設定頂點屬性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 使用 shader program
glUseProgram(shaderProgram);
// 畫東西
someOpenGLFunctionThatDrawsOurTriangle();

也許畫小東西看起來不多,但如果頂點屬性(Vertex Attribute)一多,或是有很多物體呢?
設定頂點屬性就會很麻煩,因此有 Vertex Array Object (VAO) 來將這些狀態都儲存起來,並可以透過綁定此物件來快速設定頂點屬性。

Vertex Array Object (VAO)

如果沒有綁定 VAO 則 OpenGL 可能不會畫出任何東西

頂點陣列物件 Vertex Array Object (VAO),就像 VAO 或是其他 OpenGL 的東西一樣可以被綁定,綁定後的任何 Vertex Attribute 設定都會儲存在此 VAO 中。這讓設定 Vertex Attribute 變得只要綁定不同的 VAO 就好,繁雜的 Vertex Attribute 就只要設定一次就好。

  • 一個 VAO 會儲存以下狀態

    • VAO 是否啟用
      • glEnableVertexAttribArray()/glDisableVertexAttribArray()
    • 透過 glVertexAttribPointer 設定的頂點屬性
    • 頂點屬性設定時綁定之VBO
  • 建立

    uint32_t vao;
    glGenVertexArrays(1, &vao);
    
  • 綁定

    glBindVertexArray(vao);
    

綁定 VAO 後,接著綁定與設定 VBO 的頂點屬性,之後解綁 VAO ,等到要繪製時再綁定 VAO 就好。
有了 VAO 後,整個流程看起來是這樣:

// 建立並綁定 VAO
glGenVertexArray(1, &VAO);
glBindVertexArray(VAO);

// 建立 VBO 複製頂點資料
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// 設定頂點屬性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

// ...

// 繪製
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();

一般當你打算繪製多個物體時,你首先要生成/配置所有的VAO(和必須的VBO及屬性指針),然後儲存它們供後面使用。當我們打算繪製物體的時候就拿出相應的VAO,綁定它,繪製完物體後,再解綁VAO。

三角形

要想繪製我們想要的物體,OpenGL給我們提供了glDrawArrays函數,它使用當前啟用(激活)的著色器,之前定義的頂點屬性配置,和VBO的頂點數據(通過VAO間接綁定)來繪製。

glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3); // 三角形,從0開始,畫3個

現在從上到下串連起來並執行你應該會看到

  • 完整程式碼
#include <iostream>
#include <SFML/Window.hpp>
#include <SFML/Graphics.hpp>

#include <glad/glad.h>
#include <SFML/OpenGL.hpp>

// Vertex Shader
const char *vertexShaderSource = R"glsl(
#version 450 core
layout (location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
)glsl";
// Fragment Shader
const char* fragmentShaderSoucre = R"glsl(
#version 450 core
out vec4 FragColor;

void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
)glsl";

uint32_t LoadShader(GLenum type, const char* src)
{
    uint32_t shader;
    shader = glCreateShader(type);
    glShaderSource(shader, 1, &src, nullptr);
    glCompileShader(shader);

    int success;
    char log[512];
    glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
    if(!success)
    {
        glGetShaderInfoLog(shader, 512, nullptr, log);
        printf("Error Shader %s compile error\n%s\n", type == GL_VERTEX_SHADER ? "Vertex" : "Fragment", log);
        return 0;
    }
    return shader;
}

uint32_t LinkShaderProgram(uint32_t vertex, uint32_t fragment)
{
    uint32_t program = glCreateProgram();
    glAttachShader(program, vertex);
    glAttachShader(program, fragment);
    glLinkProgram(program);
    //
    int success;
    char log[512];
    glGetProgramiv(program, GL_LINK_STATUS, &success);
    if(!success)
    {
        glGetProgramInfoLog(program, 512, nullptr, log);
        printf("Error Shader Linking error\n%s\n", log);
        return 0;
    }
    return program;
}

float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

int main()
{
    sf::RenderWindow window(sf::VideoMode(800, 600), "OpenGL", sf::Style::Default, sf::ContextSettings(
        24, // depthBits
        8,  // stencilBits
        4,  // antialiasingLevel
        4,  // majorVersion
        4   // minorVersion
    ));
    window.setVerticalSyncEnabled(true);
    // Load OpenGL functions using glad
    if (!gladLoadGL())
    {
        printf("Something went wrong!\n");
        exit(-1);
    }
    printf("OpenGL %s, GLSL %s\n", glGetString(GL_VERSION), glGetString(GL_SHADING_LANGUAGE_VERSION));
    window.setActive(true);

    // Load shader
    uint32_t vertexShader = LoadShader(GL_VERTEX_SHADER, vertexShaderSource);
    uint32_t fragmentShader = LoadShader(GL_FRAGMENT_SHADER, fragmentShaderSoucre);
    uint32_t program;
    if(vertexShader && fragmentShader)
    {
        program = LinkShaderProgram(vertexShader, fragmentShader);
        glUseProgram(program);
        glDeleteShader(vertexShader);
        glDeleteShader(fragmentShader);
    }
    else
    {
        return 1;
    }
    // Load vertices
    // VAO
    uint32_t vao;
    glGenVertexArrays(1, &vao);
    glBindVertexArray(vao);
    // VBO
    uint32_t vbo;
    glGenBuffers(1, &vbo);
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    // glBindAttribLocation(program, 0, "aPos");
    uint32_t aPos_attrib_index = glGetAttribLocation(program, "aPos");
    printf("aPos location = %d\n", aPos_attrib_index);
    glVertexAttribPointer(
        aPos_attrib_index, // Location
        3,                 // size
        GL_FLOAT,          // type
        GL_FALSE,          // normalize?
        3 * sizeof(float), // stride
        nullptr            // offsets
    );
    glEnableVertexAttribArray(aPos_attrib_index);

    bool running = true;
    while (running)
    {
        sf::Event event;
        while (window.pollEvent(event))
        {
            if (event.type == sf::Event::Closed)
                running = false;

        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        glUseProgram(program);
        glBindVertexArray(vao);
        glDrawArrays(GL_TRIANGLES, 0, 3);
        glBindVertexArray(0);

        window.display();
    }

    // release resources...
    glDeleteVertexArrays(1, &vao);
    glDeleteBuffers(1, &vbo);
    glDeleteProgram(program);

    return 0;
}

EBO / IBO

Element Buffer Object (EBO) 或 Index Buffer Object (IBO)
假設要畫一個矩形,用兩個三角形來組成一個矩形(OpenGL 主要處理三角形):

float vertices[] = {
    // 第一個三角形
    0.5f, 0.5f, 0.0f,   // 右上角
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, 0.5f, 0.0f,  // 左上角
    // 第二個三角形
    0.5f, -0.5f, 0.0f,  // 右下角
    -0.5f, -0.5f, 0.0f, // 左下角
    -0.5f, 0.5f, 0.0f   // 左上角
};

可以發現左上角與右下角被儲存了兩次,如此以來多了 50% 的額外開銷,這在有上千上萬個三角形的模型中會更糟糕。更好的方法是:儲存單獨的頂點,用另外一個陣列來表示頂點的順序。這正是 EBO 的功能。

EBO 就跟 VBO 一樣,它也是個 Buffer,但 EBO 專門儲存索引(Index)

float vertices[] = {
     0.5f, 0.5f, 0.0f,    // 右上角
     0.5f, -0.5f, 0.0f,   // 右下角
    -0.5f, -0.5f, 0.0f,  // 左下角
    -0.5f, 0.5f, 0.0f    // 左上角
};

uint32_t indices[] = {
    {0, 1, 3}, // 第一個三角形
    {1, 2, 3}  // 第二個三角形
};
  • 建立/綁定

    // 建立
    uint32_t ebo;
    ebo = glGenBuffers(1, &ebo);
    // 綁定
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
    
  • 繪製

    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
    glDrawElements(GL_TRIANGLES,  // 形狀
        6,               // 頂點數量
        GL_UNSIGNED_INT, // EBO 的 value type
        0                // offset
    );
    
    • glDrawElements() 從當前綁定的 GL_ELEMENT_ARRAY_BUFFER EBO 中拿到 index
    • VAO 也會儲存綁定的 EBO

加入 EBO 後,你的 OpenGL code 可能會長像這樣:

// 綁定 VAO
glGenVertexArray(1, &VAO);
glBindVertexArray(VAO);

// 建立 VBO 複製頂點資料
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// 建立 EBO 並複製
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

// 設定頂點屬性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

// ...

// 繪製
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
// 三角形,畫6個,EBO的type,從0開始

我們的正方形就會出現了

加上glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);就可以看裸體的三角形喔

參考資料

https://learnopengl.com/Getting-started/Hello-Triangle


上一篇
Day 9 Using OpenGL in SFML
下一篇
Day11 [OpenGL] Shader
系列文
寫遊戲初體驗30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言